Same feature · Every architecture

See the Evolution
in Real Code

We build the exact same feature — load and display a user profile from an API — across every Android architecture. Watch the code transform as separation of concerns, testability, and predictability improve with each era.

Feature: UserProfileActivity — fetch user by ID, show name/email/avatar, handle loading + error states
00
No Architecture · ~2008
The God Activity
Everything — UI, networking, parsing, business logic — crammed into one Activity. No separation. Maximum chaos.
⚠ The problem
Activity does everything. You can't test any of it. Rotate the phone → crash. Add a feature → break three others.
✦ What "worked"
Easy to start. No boilerplate. For a 1-screen demo it's fine. In production it becomes a 1000-line monster.
UserProfileActivity.kt
// ❌ NO ARCHITECTURE — Everything in one Activity
// UI + Network + Business Logic + State = one God class

class UserProfileActivity : AppCompatActivity() {

    // UI references grabbed manually — tightly coupled to layout
    private lateinit var tvName: TextView
    private lateinit var tvEmail: TextView
    private lateinit var ivAvatar: ImageView
    private lateinit var progressBar: ProgressBar
    private lateinit var tvError: TextView

    // Raw OkHttp — no abstraction, no reuse
    private val client = OkHttpClient()

    @Override
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user_profile)

        tvName      = findViewById(R.id.tvName)
        tvEmail     = findViewById(R.id.tvEmail)
        ivAvatar    = findViewById(R.id.ivAvatar)
        progressBar = findViewById(R.id.progressBar)
        tvError     = findViewById(R.id.tvError)

        val userId = intent.getStringExtra("USER_ID") ?: return

        // ❌ Network call directly on main thread? Crash!
        // ❌ Must use Thread — no coroutines, no lifecycle awareness
        fetchUser(userId)
    }

    private fun fetchUser(userId: String) {
        // ❌ Show loading state — scattered UI logic
        progressBar.visibility = View.VISIBLE
        tvError.visibility     = View.GONE

        // ❌ Raw thread — rotate phone here and the Activity is GONE
        // ❌ but this Thread is still running → NullPointerException
        Thread {
            try {
                val request = Request.Builder()
                    .url("https://api.example.com/users/$userId")
                    .build()

                val response = client.newCall(request).execute()
                val body     = response.body?.string() ?: throw Exception("Empty body")

                // ❌ Manual JSON parsing — no Gson/Moshi, no data class
                val json   = JSONObject(body)
                val name   = json.getString("name")
                val email  = json.getString("email")
                val avatar = json.getString("avatarUrl")

                // ❌ runOnUiThread needed — easy to forget, easy to crash
                runOnUiThread {
                    progressBar.visibility = View.GONE
                    tvName.text  = name
                    tvEmail.text = email

                    // ❌ Glide called inside Thread callback — messy
                    Glide.with(this).load(avatar).into(ivAvatar)
                }
            } catch (e: Exception) {
                runOnUiThread {
                    progressBar.visibility = View.GONE
                    tvError.visibility     = View.VISIBLE
                    tvError.text           = "Error: ${e.message}"
                }
            }
        }.start()
    }

    // ❌ Zero unit tests possible — everything needs a real Android device
    // ❌ Rotate screen → thread crashes into dead Activity
    // ❌ Need the same logic elsewhere? Copy-paste only option
}
Key problems visible in this code: Thread running after Activity is destroyed causes NPE on rotation. JSON parsing, network, and UI rendering are all in the same function. No way to unit test any of this. Zero reusability.
01
MVC · ~2010
Model — View — Controller
We extract a User model and a UserController class. The Activity becomes the "View". Business logic moves out, but the Activity is still both View and Controller.
⚠ Inherited problem
Activity is still the Controller AND the View. Can't test Controller in isolation — it has Activity/Context references baked in.
✦ What improved
Model (User data class + UserService) is now separately testable. Business logic lives outside the Activity. First real separation.
MVC
// ✅ MODEL — Pure data class, zero Android dependencies
// This can be unit-tested anywhere on any JVM

data class User(
    val id:        String,
    val name:      String,
    val email:     String,
    val avatarUrl: String
)
// ✅ MODEL — UserService handles data fetching logic
// Separate from Activity — reusable, partially testable

interface UserCallback {
    fun onSuccess(user: User)
    fun onError(message: String)
}

class UserService {
    private val retrofit = Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    private val api = retrofit.create(UserApi::class.java)

    fun getUser(userId: String, callback: UserCallback) {
        api.getUser(userId).enqueue(object : Callback<User> {
            override fun onResponse(call: Call<User>, response: Response<User>) {
                if (response.isSuccessful()) {
                    callback.onSuccess(response.body()!!)
                } else {
                    callback.onError("Server error ${response.code()}")
                }
            }
            override fun onFailure(call: Call<User>, t: Throwable) {
                callback.onError(t.message ?: "Unknown error")
            }
        })
    }
}
// ⚠ CONTROLLER — Owns logic but still references Activity
// ❌ Can't test without Android framework due to Context

class UserController(private val activity: UserProfileActivity) {

    private val userService = UserService()

    fun loadUser(userId: String) {
        // Tell View to show loading
        activity.showLoading()

        userService.getUser(userId, object : UserCallback {
            override fun onSuccess(user: User) {
                // ❌ activity could be destroyed by now!
                activity.showUser(user)
            }
            override fun onError(message: String) {
                activity.showError(message)
            }
        })
    }
}
// ⚠ VIEW — Activity as View. Simpler than God Activity but
// ❌ It creates the Controller itself — tight coupling
// ❌ Controller has direct Activity reference → memory leak risk

class UserProfileActivity : AppCompatActivity() {

    private lateinit var binding:    ActivityUserProfileBinding
    private lateinit var controller: UserController

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding     = ActivityUserProfileBinding.inflate(layoutInflater)
        controller  = UserController(this) // ❌ passes 'this' — tight coupling
        setContentView(binding.root)

        val userId = intent.getStringExtra("USER_ID") ?: return
        controller.loadUser(userId)
    }

    // "View" methods called by Controller
    fun showLoading() {
        binding.progressBar.visibility = View.VISIBLE
        binding.errorText.visibility   = View.GONE
        binding.content.visibility     = View.GONE
    }

    fun showUser(user: User) {
        binding.progressBar.visibility = View.GONE
        binding.content.visibility     = View.VISIBLE
        binding.tvName.text             = user.name
        binding.tvEmail.text            = user.email
        Glide.with(this).load(user.avatarUrl).into(binding.ivAvatar)
    }

    fun showError(message: String) {
        binding.progressBar.visibility = View.GONE
        binding.errorText.visibility   = View.VISIBLE
        binding.errorText.text          = message
    }
}
Progress: User is now a proper data class. UserService is reusable and partially testable. Still broken: Controller holds Activity reference — rotate screen mid-request → crash. Controller can't be unit-tested (it needs the Activity). View and Controller are tightly coupled.
02
MVP · ~2013
Model — View — Presenter
We introduce a Contract interface. The Presenter holds an IUserView reference — not the Activity. Now the Presenter is 100% unit-testable. The Activity just implements the interface.
⚠ What MVP introduced
Requires a View interface for every screen. Manual attachView() / detachView() in every lifecycle method — forget once → memory leak or crash.
✅ What improved over MVC
Presenter has ZERO Activity references. It only knows IUserView. Mock that interface in tests — Presenter is fully testable with pure JUnit, no Android needed.
MVP
// ✅ CONTRACT — The interface between View and Presenter
// This is the KEY innovation of MVP

interface UserContract {

    // What the VIEW must provide (Activity implements this)
    interface View {
        fun showLoading()
        fun hideLoading()
        fun showUser(user: User)
        fun showError(message: String)
    }

    // What the PRESENTER must provide (Activity calls this)
    interface Presenter {
        fun attachView(view: View)
        fun detachView()
        fun loadUser(userId: String)
    }
}
// ✅ PRESENTER — Pure Kotlin, zero Android imports
// Holds IView reference (not Activity) → unit-testable!

class UserPresenter(
    private val repository: UserRepository
) : UserContract.Presenter {

    // ✅ Interface reference — not the Activity itself
    private var view: UserContract.View? = null

    override fun attachView(view: UserContract.View) { this.view = view }
    override fun detachView()                            { this.view = null  }

    override fun loadUser(userId: String) {
        view?.showLoading()

        repository.getUser(userId, object : UserCallback {
            override fun onSuccess(user: User) {
                // ✅ Safe null check — view is null if Activity was destroyed
                view?.hideLoading()
                view?.showUser(user)
            }
            override fun onError(message: String) {
                view?.hideLoading()
                view?.showError(message)
            }
        })
    }
}
// ✅ MODEL — Repository pattern separates data concerns
// Can be swapped for fake in tests

interface UserRepository {
    fun getUser(userId: String, callback: UserCallback)
}

class UserRepositoryImpl(private val api: UserApi) : UserRepository {

    override fun getUser(userId: String, callback: UserCallback) {
        api.getUser(userId).enqueue(object : Callback<User> {
            override fun onResponse(call: Call<User>, res: Response<User>) {
                if (res.isSuccessful()) callback.onSuccess(res.body()!!)
                else callback.onError("Server error")
            }
            override fun onFailure(call: Call<User>, t: Throwable) {
                callback.onError(t.message ?: "Error")
            }
        })
    }
}
// ✅ VIEW — Activity is now a thin shell
// ⚠ Must remember attachView/detachView — easy to forget!

class UserProfileActivity : AppCompatActivity(), UserContract.View {

    private lateinit var binding:   ActivityUserProfileBinding
    private lateinit var presenter: UserContract.Presenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding   = ActivityUserProfileBinding.inflate(layoutInflater)
        presenter = UserPresenter(UserRepositoryImpl(ApiClient.api))
        setContentView(binding.root)

        presenter.attachView(this) // ⚠ Don't forget!
        intent.getStringExtra("USER_ID")?.let { presenter.loadUser(it) }
    }

    override fun onDestroy() {
        presenter.detachView() // ⚠ Don't forget! Memory leak if missed
        super.onDestroy()
    }

    override fun showLoading() {
        binding.progressBar.visibility = View.VISIBLE
        binding.content.visibility     = View.GONE
    }
    override fun hideLoading() { binding.progressBar.visibility = View.GONE }
    override fun showUser(user: User) {
        binding.content.visibility = View.VISIBLE
        binding.tvName.text         = user.name
        binding.tvEmail.text        = user.email
        Glide.with(this).load(user.avatarUrl).into(binding.ivAvatar)
    }
    override fun showError(msg: String) {
        binding.errorText.visibility = View.VISIBLE
        binding.errorText.text        = msg
    }
}
// ✅ UNIT TEST — No Android SDK needed at all!
// This is MVP's biggest win

class UserPresenterTest {

    // Mock the View interface — not the Activity
    private val mockView       = mock(UserContract.View::class.java)
    private val mockRepository = mock(UserRepository::class.java)
    private val presenter      = UserPresenter(mockRepository)

    @Before
    fun setup() { presenter.attachView(mockView) }

    @Test
    fun `loadUser shows loading then user on success`() {
        val fakeUser = User("1", "Alice", "alice@test.com", "http://...")

        // Arrange: repository will call onSuccess
        whenever(mockRepository.getUser(any(), any())).thenAnswer {
            (it.arguments[1] as UserCallback).onSuccess(fakeUser)
        }

        // Act
        presenter.loadUser("1")

        // Assert — verify View methods were called correctly
        verify(mockView).showLoading()
        verify(mockView).hideLoading()
        verify(mockView).showUser(fakeUser)
        verifyNoMoreInteractions(mockView)
    }
}
Big win: Check the test file — pure JUnit, zero Android, full Presenter coverage. Still painful: Every screen needs a Contract interface (2–3 interfaces per feature). Manual attachView/detachView with no safety net. Rotation recreates Activity — Presenter must be kept alive manually or state is lost.
03
MVVM · 2017 — Present
Model — View — ViewModel
Google Architecture Components arrive. ViewModel survives rotation natively. StateFlow replaces callbacks. Zero manual lifecycle management. Coroutines make async clean.
⚠ Remaining issues
Multiple StateFlow properties can result in inconsistent states (isLoading=true AND data non-null at the same time). Events (navigation) re-deliver on rotation with SharedFlow. No standard single source of truth.
✅ What improved over MVP
ViewModel survives config changes with zero code. No View references in ViewModel ever. viewModelScope cancels all coroutines automatically. No more attachView/detachView. Official Jetpack support.
MVVM
// ✅ VIEWMODEL — Survives rotation, zero View references
// viewModelScope auto-cancels all coroutines on clear()

class UserViewModel(private val repository: UserRepository) : ViewModel() {

    // ⚠ Multiple StateFlows — can get out of sync
    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    fun loadUser(userId: String) {
        // ✅ viewModelScope — coroutine cancelled when ViewModel is cleared
        viewModelScope.launch {
            _isLoading.value = true
            _error.value     = null

            // ✅ suspend function — reads like synchronous code
            repository.getUser(userId)
                .onSuccess { user ->
                    _user.value      = user
                    _isLoading.value = false
                }
                .onFailure { t ->
                    _error.value     = t.message
                    _isLoading.value = false
                }
        }
    }
}
// ✅ REPOSITORY — Suspend functions, single source of truth
// Combines local DB (Room) + remote API (Retrofit)

interface UserRepository {
    suspend fun getUser(userId: String): Result<User>
}

class UserRepositoryImpl(
    private val api:     UserApi,
    private val userDao: UserDao  // Room DAO
) : UserRepository {

    override suspend fun getUser(userId: String): Result<User> {
        // Try cache first
        userDao.getUser(userId)?.let { return Result.success(it.toDomain()) }

        // Fetch from network
        return runCatching {
            val dto = api.getUser(userId)
            userDao.insertUser(dto.toEntity()) // Cache locally
            dto.toDomain()
        }
    }
}
// ✅ VIEW — Activity is very thin, just observes
// ✅ viewModels() delegate handles ViewModel lifecycle

class UserProfileActivity : AppCompatActivity() {

    private lateinit var binding: ActivityUserProfileBinding

    // ✅ ViewModels survive rotation — no manual save/restore
    private val viewModel: UserViewModel by viewModels {
        UserViewModelFactory(UserRepositoryImpl(ApiClient.api, AppDatabase.dao))
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityUserProfileBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // ✅ Collect lifecycle-safely — auto-stops when Activity is stopped
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch { viewModel.isLoading.collect { setLoading(it) } }
                launch { viewModel.user.collect      { it?.let { u -> showUser(u) } } }
                launch { viewModel.error.collect     { it?.let { e -> showError(e) } } }
            }
        }

        intent.getStringExtra("USER_ID")?.let { viewModel.loadUser(it) }
    }

    private fun setLoading(loading: Boolean) {
        binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
    }
    private fun showUser(user: User) {
        binding.tvName.text  = user.name
        binding.tvEmail.text = user.email
        Glide.with(this).load(user.avatarUrl).into(binding.ivAvatar)
    }
    private fun showError(msg: String) { binding.errorText.text = msg }
}
// ✅ COMPOSE VERSION — collectAsState() + MVVM = perfect pair

@Composable
fun UserProfileScreen(
    userId:    String,
    viewModel: UserViewModel = hiltViewModel()
) {
    val isLoading by viewModel.isLoading.collectAsState()
    val user      by viewModel.user.collectAsState()
    val error     by viewModel.error.collectAsState()

    LaunchedEffect(userId) { viewModel.loadUser(userId) }

    Box(modifier = Modifier.fillMaxSize()) {
        when {
            isLoading         -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            error != null     -> Text(text = error!!, color = Color.Red)
            user  != null     -> UserContent(user = user!!)
        }
    }
}
// ✅ VIEWMODEL TEST — Uses TestCoroutineDispatcher
// No Android framework needed

@ExperimentalCoroutinesApi
class UserViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val fakeRepo = mockk<UserRepository>()
    private val viewModel = UserViewModel(fakeRepo)

    @Test
    fun `loadUser emits user on success`() = runTest {
        val fakeUser = User("1", "Alice", "alice@test.com", "")
        coEvery { fakeRepo.getUser("1") } returns Result.success(fakeUser)

        viewModel.loadUser("1")

        assertEquals(fakeUser, viewModel.user.value)
        assertEquals(false,    viewModel.isLoading.value)
        assertNull(            viewModel.error.value)
    }
}
Huge upgrade: ViewModel lives through rotation. viewModelScope cancels network calls on exit. No more callback hell — suspend functions. Notice the problem: Three separate StateFlow fields can drift — set isLoading=false but forget to set user value → UI in broken intermediate state. MVI fixes this.
04
MVI · 2019 — Present
Model — View — Intent
One sealed UiState replaces all the scattered StateFlows. A single atomic copy() update means impossible states become structurally impossible. Pure unidirectional data flow.
⚠ Trade-off
More classes per feature (State, Intent, Effect). Steeper learning curve. Reducer pattern may feel foreign at first. Slight overhead copying data class each update.
✅ What improved over MVVM
Single UiState — impossible states are impossible. Every state is reproducible and loggable. Side effects (navigation) via Channel (consumed once, no re-delivery). Reducer is a pure function — trivially tested.
MVI
// ✅ SINGLE STATE — All possible UI states in one sealed class
// isLoading=true AND data≠null simultaneously? STRUCTURALLY IMPOSSIBLE

// Every possible state the screen can be in
data class UserUiState(
    val isLoading: Boolean     = false,
    val user:      User?        = null,
    val error:     String?      = null
)

// User actions become explicit Intent events
sealed class UserIntent {
    data class LoadUser(val userId: String) : UserIntent()
    object     RetryLoad                    : UserIntent()
    object     DismissError                 : UserIntent()
}

// One-off side effects — NOT part of persistent state
// Channel ensures each effect is consumed exactly ONCE
sealed class UserEffect {
    data class ShowToast(val message: String) : UserEffect()
    object     NavigateBack                   : UserEffect()
}
// ✅ VIEWMODEL with Reducer — processes Intents → emits new State
// ✅ updateState() is atomic — no intermediate inconsistent states

class UserViewModel(private val repository: UserRepository) : ViewModel() {

    // ✅ ONE state object — impossible states are impossible
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    // ✅ Channel — effects consumed exactly once, no re-delivery on rotation
    private val _effect = Channel<UserEffect>(Channel.BUFFERED)
    val effect: Flow<UserEffect> = _effect.receiveAsFlow()

    // ✅ Single entry point for all user actions
    fun handleIntent(intent: UserIntent) {
        when (intent) {
            is UserIntent.LoadUser  -> loadUser(intent.userId)
            is UserIntent.RetryLoad  -> _uiState.value.user?.id?.let { loadUser(it) }
            is UserIntent.DismissError -> updateState { copy(error = null) }
        }
    }

    private fun loadUser(userId: String) {
        viewModelScope.launch {
            // ✅ Single atomic update — all fields change together
            updateState { copy(isLoading = true, error = null, user = null) }

            repository.getUser(userId)
                .onSuccess { user ->
                    updateState { copy(isLoading = false, user = user) }
                    _effect.send(UserEffect.ShowToast("Welcome, ${user.name}!"))
                }
                .onFailure { t ->
                    updateState { copy(isLoading = false, error = t.message) }
                }
        }
    }

    // ✅ Helper — atomic state update, thread-safe
    private fun updateState(reduce: UserUiState.() -> UserUiState) {
        _uiState.update { it.reduce() }
    }
}
// ✅ COMPOSE + MVI — single state drives the entire UI
// collectAsState() + when(uiState) = perfectly predictable

@Composable
fun UserProfileScreen(
    userId:    String,
    viewModel: UserViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    val context = LocalContext.current

    // ✅ Effect consumed once — no re-delivery on recompose/rotation
    LaunchedEffect(Unit) {
        viewModel.effect.collect { effect ->
            when (effect) {
                is UserEffect.ShowToast   -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
                is UserEffect.NavigateBack -> { /* navigate */ }
            }
        }
    }

    LaunchedEffect(userId) {
        viewModel.handleIntent(UserIntent.LoadUser(userId))
    }

    // ✅ Renders based on SINGLE state — always consistent
    Box(modifier = Modifier.fillMaxSize()) {
        when {
            uiState.isLoading            -> LoadingView()
            uiState.error != null        -> ErrorView(
                message = uiState.error!!,
                onRetry = { viewModel.handleIntent(UserIntent.RetryLoad) }
            )
            uiState.user != null         -> UserContent(user = uiState.user!!)
            else                          -> EmptyView()
        }
    }
}
// ✅ PUREST TESTS — State-in → State-out, no mocks needed for assertions
// Just check: "given state X + intent Y → state Z"

class UserViewModelTest {

    @get:Rule val dispatcher = MainDispatcherRule()

    private val fakeRepo   = mockk<UserRepository>()
    private val viewModel  = UserViewModel(fakeRepo)

    @Test
    fun `LoadUser intent transitions to loading then success state`() = runTest {
        val fakeUser = User("1", "Alice", "alice@test.com", "")
        coEvery { fakeRepo.getUser("1") } returns Result.success(fakeUser)

        viewModel.handleIntent(UserIntent.LoadUser("1"))

        // Assert final state — single object, easy to read
        assertEquals(
            UserUiState(isLoading = false, user = fakeUser, error = null),
            viewModel.uiState.value
        )
    }

    @Test
    fun `DismissError intent clears error from state`() = runTest {
        // Seed state with an error
        coEvery { fakeRepo.getUser(any()) } throws Exception("Network error")
        viewModel.handleIntent(UserIntent.LoadUser("1"))
        assertNotNull(viewModel.uiState.value.error)

        // Dismiss — error should be gone
        viewModel.handleIntent(UserIntent.DismissError)
        assertNull(viewModel.uiState.value.error)
    }
}
Notice: The updateState { copy(...) } pattern atomically transitions state — you can never be half-loading-half-success. Tests are the simplest yet — compare entire state objects, no Mockito verify() chains. Effects via Channel solve the rotation re-delivery bug permanently.
05
Clean Architecture + MVI · 2021 — Present
Clean Architecture
We add a Domain layer between Presentation and Data. The GetUserUseCase contains business logic. The Domain module has zero Android imports — pure Kotlin. The Dependency Rule is enforced.
⚠ Complexity cost
More files per feature: Domain model, Data model, DTO, two mappers, a Repository interface, Use Case class. Overkill for a 2-screen app. Requires Hilt/Dagger for DI.
✅ What it adds over MVI alone
Business logic in Use Cases — reusable across ViewModels. Domain layer is framework-free. Swap Room for Firestore — ViewModel doesn't change. ViewModel doesn't change when business rules change. Perfect isolation at every boundary.
Clean Architecture — :domain module (zero Android imports) + :data + :ui
// ✅ DOMAIN LAYER — :domain module
// Pure Kotlin. Zero Android SDK imports. The most stable code.
// This model is OWNED by Domain — it reflects business concepts,
// NOT API response shape or database schema

data class User(           // Domain model — NOT a DTO, NOT a Room entity
    val id:          String,
    val name:        String,
    val email:       String,
    val avatarUrl:   String,
    val isPremium:   Boolean  // Business concept — not in API, derived by domain
)

// ✅ Sealed Result type owned by Domain — not Android's Result class
sealed class DomainResult<out T> {
    data class Success<T>(val data: T) : DomainResult<T>()
    data class Error(val exception: DomainException) : DomainResult<Nothing>()
}

// Domain defines its OWN exceptions — not IOException or HttpException
sealed class DomainException(message: String) : Exception(message) {
    class UserNotFound(id: String)  : DomainException("User $id not found")
    class NetworkError(cause: String): DomainException(cause)
    object Unauthorized              : DomainException("Unauthorized")
}
// ✅ USE CASE — :domain module, pure Kotlin, single responsibility
// THIS is where business logic lives — not in ViewModel, not in Repository
// "A user can only be loaded if the current session is valid"
// "Premium status is derived from subscription expiry date"

class GetUserUseCase(
    private val userRepository:    UserRepository,     // Domain interface
    private val sessionRepository:  SessionRepository  // Domain interface
) {
    // ✅ suspend operator fun — call as: getUser("123")
    suspend operator fun invoke(userId: String): DomainResult<User> {

        // Business rule 1: Must be authenticated
        if (!sessionRepository.isAuthenticated()) {
            return DomainResult.Error(DomainException.Unauthorized)
        }

        return userRepository.getUser(userId).fold(
            onSuccess = { userDto ->
                // Business rule 2: Premium derived from subscription end date
                val isPremium = userDto.subscriptionEndDate?.isAfterNow() ?: false
                DomainResult.Success(
                    User(
                        id        = userDto.id,
                        name      = userDto.name,
                        email     = userDto.email,
                        avatarUrl = userDto.avatarUrl,
                        isPremium = isPremium  // ← Business logic in Domain, not ViewModel
                    )
                )
            },
            onFailure = { t ->
                DomainResult.Error(DomainException.NetworkError(t.message ?: "Error"))
            }
        )
    }
}
// ✅ REPOSITORY INTERFACE — :domain module
// Domain DEFINES the interface. Data layer IMPLEMENTS it.
// Dependency Inversion Principle — the crown jewel of Clean Architecture
// Swap SQLite for Firestore? Only :data module changes. Domain is untouched.

interface UserRepository {
    suspend fun getUser(userId: String): Result<UserDto>
    suspend fun saveUser(user: User): Result<Unit>
}

interface SessionRepository {
    suspend fun isAuthenticated(): Boolean
    suspend fun getCurrentUserId(): String?
}

// DTO lives at the boundary between Domain and Data
data class UserDto(
    val id:                  String,
    val name:                String,
    val email:               String,
    val avatarUrl:           String,
    val subscriptionEndDate: String? // Raw from API — Domain interprets this
)
// ✅ REPOSITORY IMPLEMENTATION — :data module
// Knows about Room, Retrofit. Domain knows NOTHING about these.

class UserRepositoryImpl(
    private val api:     UserApi,
    private val userDao: UserDao
) : UserRepository {           // ← Implements Domain interface

    override suspend fun getUser(userId: String): Result<UserDto> {
        // 1. Try Room cache first
        userDao.getUser(userId)?.let {
            return Result.success(it.toDto())  // Entity → DTO mapper
        }

        // 2. Fetch from Retrofit
        return runCatching {
            val response = api.getUser(userId)  // UserApiResponse (API model)
            userDao.insertUser(response.toEntity()) // ApiResponse → Room Entity
            response.toDto()                       // ApiResponse → DTO
        }
    }

    override suspend fun saveUser(user: User): Result<Unit> =
        runCatching { userDao.insertUser(user.toEntity()) }
}

// ✅ Mappers keep each layer's model clean
fun UserEntity.toDto()     = UserDto(id, name, email, avatarUrl, subscriptionEndDate)
fun UserApiResponse.toDto() = UserDto(id, name, email, avatarUrl, subscriptionEndDate)
fun UserApiResponse.toEntity() = UserEntity(id, name, email, avatarUrl, subscriptionEndDate)
// ✅ VIEWMODEL — :ui module, uses Use Case (not Repository directly!)
// ViewModel is THIN — just orchestrates Use Case calls and maps results to UiState
// Notice: no business logic here. "isPremium" logic is in the Use Case.

@HiltViewModel
class UserViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase  // ← Use Case, not Repository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UserUiState())
    val uiState = _uiState.asStateFlow()

    private val _effect = Channel<UserEffect>(Channel.BUFFERED)
    val effect = _effect.receiveAsFlow()

    fun handleIntent(intent: UserIntent) {
        when (intent) {
            is UserIntent.LoadUser   -> loadUser(intent.userId)
            is UserIntent.RetryLoad  -> _uiState.value.user?.id?.let { loadUser(it) }
            is UserIntent.DismissError -> updateState { copy(error = null) }
        }
    }

    private fun loadUser(userId: String) {
        viewModelScope.launch {
            updateState { copy(isLoading = true, error = null) }

            // ✅ Use Case called — business rules run inside, ViewModel stays thin
            when (val result = getUserUseCase(userId)) {
                is DomainResult.Success -> {
                    updateState { copy(isLoading = false, user = result.data) }
                    if (result.data.isPremium) {
                        _effect.send(UserEffect.ShowToast("✦ Premium member!"))
                    }
                }
                is DomainResult.Error -> {
                    val msg = when (result.exception) {
                        is DomainException.Unauthorized  -> "Please log in again"
                        is DomainException.UserNotFound  -> "User not found"
                        is DomainException.NetworkError  -> result.exception.message ?: "Network error"
                    }
                    updateState { copy(isLoading = false, error = msg) }
                }
            }
        }
    }

    private fun updateState(reduce: UserUiState.() -> UserUiState) = _uiState.update { it.reduce() }
}
// ✅ DEPENDENCY INJECTION — Hilt wires everything together
// The entire dependency graph is defined here — not scattered across classes

@Module
@InstallIn(SingletonComponent::class)
object UserModule {

    @Provides @Singleton
    fun provideUserApi(retrofit: Retrofit): UserApi =
        retrofit.create(UserApi::class.java)

    @Provides @Singleton
    fun provideUserRepository(   // ← Returns Domain interface type
        api: UserApi, dao: UserDao
    ): UserRepository = UserRepositoryImpl(api, dao)

    @Provides
    fun provideGetUserUseCase(
        userRepo:    UserRepository,
        sessionRepo: SessionRepository
    ): GetUserUseCase = GetUserUseCase(userRepo, sessionRepo)
}
// ✅ USE CASE TESTS — Pure Kotlin, no Android, no Compose, no Room
// Tests are deterministic: inject fake repos → assert business logic
// This is the cleanest, fastest test suite in the entire codebase

class GetUserUseCaseTest {

    private val userRepo    = mockk<UserRepository>()
    private val sessionRepo = mockk<SessionRepository>()
    private val useCase     = GetUserUseCase(userRepo, sessionRepo)

    @Test
    fun `returns Unauthorized when not authenticated`() = runTest {
        coEvery { sessionRepo.isAuthenticated() } returns false

        val result = useCase("1")

        assertIs<DomainResult.Error>(result)
        assertIs<DomainException.Unauthorized>(result.exception)
    }

    @Test
    fun `marks user as premium when subscription is active`() = runTest {
        coEvery { sessionRepo.isAuthenticated() } returns true
        coEvery { userRepo.getUser("1") } returns Result.success(
            UserDto("1", "Alice", "alice@test.com", "", subscriptionEndDate = "2099-01-01")
        )

        val result = useCase("1")

        assertIs<DomainResult.Success<User>>(result)
        assertTrue(result.data.isPremium)  // ← Business rule tested in isolation
    }

    @Test
    fun `marks user as NOT premium when subscription expired`() = runTest {
        coEvery { sessionRepo.isAuthenticated() } returns true
        coEvery { userRepo.getUser("1") } returns Result.success(
            UserDto("1", "Bob", "bob@test.com", "", subscriptionEndDate = "2020-01-01")
        )

        val result = useCase("1")

        assertIs<DomainResult.Success<User>>(result)
        assertFalse(result.data.isPremium)
    }
}
The payoff: Look at the Use Case test — it tests the business rule "isPremium is derived from subscriptionEndDate" in complete isolation. No Activity, no ViewModel, no Room, no Retrofit, no Android. Just pure logic. This is the summit of Android architecture: every layer independently testable, every boundary explicit, every dependency pointing inward.